Mar 31, 2023

Diversão com referências em C# - Parte II / II

Read this post in English.

Lire cet post en Français.

Em meu último post apresentei o programa abaixo seguido de algumas perguntas...

Mesmo sendo controverso e codificado explicitamente para expor um comportamento, o mesmo não é irrealista; conforme a complexidade de um programa aumenta, aumenta a probabilidade de código como este ser introduzido. Assim sendo, sem mais delongas vamos tentar responder cada uma das questões...

 

Qual é a saída do programa ?

Infelizmente a saída deste programa não é determinística e provavelmente irá variar dependendo da combinação de processador/OS em questão; pior, ao executar o programa várias vezes na mesma máquina observei variações no resultado.

Contudo, pelo menos uma parte do resultado é completamente determinístico: a saída do método
M2() deve ser sempre algo como:

---------M2----------
 4
 3
 2
 1

Por outro lado, a saída do método M3() é indefinido e se assemelha a "lixo" ou random, algo como:

      ---------M3----------
      RefStruct created.
      fe
     7f
     0
     0

mas, antes de condená-lo, exploremos um pouco mais a fundo as call stacks nas  execuções dos métodos M2()/M3()...

Pouvez-vous repérer/expliquer les problèmes dans le code ?

Afin de démontrer plus facilement le problème, je vais présenter une série de call stacks simplifiées dans lesquelles:

  1. Pilhas (stack) são localizadas em endereços mais altos e avancam para endereços mais baixos, ou seja, a medida que dados são empilhados a pilha cresce no sentido de endereços mais baixos de memória.

  2. Por motivos de simplificação cada elemento empilhado é assumido consumir 1 entrada na pilha independente do tamanho (em bytes) real do elemento.

  3. Áreas cinza representam partes da pilha utilizadas por métodos executados anteriormente, ou seja, foram alocadas, utilizadas durante a execução do método, e desalocadas quando o método finalizou.
Mesmo não sendo 100% precisa esta representação de pilha nos servirá muito bem neste post uma vez que abstrai detalhes não importantes.

Assim, vamos começar pela execução do método
M2() :

A figura 1 representa o estado da pilha na sequência de chamada Main() -> M2() -> Instantiate() na qual Main() executou o método M2() passando o valor 0x01020304 para o parâmetro v (o qual foi armazenado na posição 98 da pilha); M2(), por sua vez, executou o método Instantiate() passando uma referência para v, em outras palavras, o endereço de v na pilha, ou seja, 98 como o valor do parâmetro i de Instantiate() o qual alocou uma instância do tipo RefStruct (representado na pilha como uma variável chamada tmp) inicializada com o valor do parâmetro i (ou seja, 98). 

Uma vez inicializada a variável tmp é retornada por valor para o método M2() (figura 2), o qual armazena este resultado na variável local rs. Observe que neste momento o valor 98 é armazenado no campo rs.value (ou em outras palavras rs.value referencia o endereço 98).

Por fim, M2() executa o método Dump() (figura 3) passando uma referência à rs, ou seja, o endereço 97; note que este último reutilisa o endereço 96 (que havia utilizado anteriormente para armazenar o parâmetro i de Instantiate()). Neste ponto Dump() interpretará o conteúdo da posição 97 como uma instância RefStruct e em seguida imprimirá  o valor referenciado pelo campo rs.value. Como este é um campo do tipo referência o valor a ser utilizado será o conteúdo do endereço 98 (valor do campo rs.value) e assim os quatro octetos representando o valor 0x01020304 serão impressos.

Agora, contraste isso com a sequência de execução do método
M3():

Na figura 1 acima temos representada a sequência de chamada  Main() -> M3() -> InstantiateAndLog() -> Instantiate(), muito similar à sequência de execução do método M2() mas com uma diferença crucial: M3() executa um método  intermediário (InstantiateAndLog()) passando 0x01020304 como valor do parâmetro v; este último, por sua vez, executa o método Instantiate() passando como parâmetro uma referência para seu próprio parâmetro v, ou seja, o endereço 96; de forma similar à execução anterior Instantiate() aloca uma instância do tipo RefStruct (armazenada no endereço  93, representada na pilha acima por uma variável chamada tmp) inicializando o campo value com a referência recebida como parâmetro, ou seja, o endereço 96.

A seguir, tmp é retornada  para InstantiateAndLog() (figura 2), e depois à M3() (figura 3) o qual armazena a mesma na variável local rs  (endereço 97) como indicado abaixo:

Observe que a estrutura retornada para M3() possui o campo value com o valor 96, ou seja, um endereço que já não é mais válido (o último endereço válido na pilha neste instante é 97) e que qualquer acesso ao mesmo apresentará um comportamento indefinido.

O comportamento observado dependerá de como este endereço será utilisado; em nosso caso específico, após retornar de
InstantiateAndLog(), M3() executa o método Dump() (figura 4) passando rs como uma referência e sobrescrevendo o valor precedente contino do endereço 96. Dump() por sua vez, ao executar o método BitConvert.GetBytes(), irá passar o valor 97 ao invés de 0x01020304 (isto porque o parâmetro rs representa uma referência para uma instância RefStruct,  assim sendo Dump() irá ler o conteúdo armazenado em tal parâmetro (97) e interpretar o contéudo daquela posição de memória (corretamente) como uma referência para RefStruct com o campo value armazendo o valor 96; como próximo passo a posição de memória referenciada por esse campo (96) será interpretado como um int e assim o valor final passado para BitConvert.GetBytes() será 97 ao invés de 0x01020304.

Como poderíamos evitar este problema ?

Resposta curta: sempre questione o código; a utilização do modificador unsafe deveria soar um alerta vermelho e levar os desenvolvedores a questionar o motivo de tal modificador ser necessário e o que poderia ser unsafe no código; removê-lo faz com que o compilador emita o seguinte erro:

error CS8166: Cannot return a parameter by reference 'v' because it is not a ref parameter

Note que mesmo com o modificador unsafe presente o compilador fez o melhor que podia para nos alertar sobre o potencial problema emitindo a seguinte warning (que foi prontamente ignorada é claro):

warning CS9087: This returns a parameter by reference 'v' but it is not a ref parameter

Finalizando, para mim a forma mais eficaz de detectar e evitar problemas compreende em i) ter boas práticas de devsenvolvimento (por exemplo, configurar warnings para serem tratadas como erros, ter um processo de code review bem estabelecido, etc.), ii) ter um bom entendimento das técnologias/bibliotecas utilisados, iii) jamais ignorar warnings do compilador e finalmente iv) jamais se sentir intimidado e tentado a não questionar algum aspécto do código durante um code review.

Have fun!

Adriano


No comments: